Skip to content

Magit-style git log plugin + buffer-group/rendering fixes#1565

Merged
sinelaw merged 42 commits intomasterfrom
claude/modernize-git-log-plugin-ZmH4O
Apr 14, 2026
Merged

Magit-style git log plugin + buffer-group/rendering fixes#1565
sinelaw merged 42 commits intomasterfrom
claude/modernize-git-log-plugin-ZmH4O

Conversation

@sinelaw
Copy link
Copy Markdown
Owner

@sinelaw sinelaw commented Apr 14, 2026

Summary

Rewrites the git_log plugin around the buffer-group API, extracts its commit-rendering code into a reusable library, and fixes a pile of engine-level bugs that surfaced while building it (panel scrolling, cursor routing across splits, tab coloring, TypeScript highlighting, …).

Git log plugin

  • Buffer-group layout — Magit-style toolbar | (log | detail) in a
    single tab, with live preview: scrolling through the commit list
    re-renders the detail panel (debounced). fetchCommitShow caches per
    hash so rapid j/k doesn't respawn git show.
  • Clickable toolbar — each keybinding hint renders as a styled
    button; clicking dispatches the handler. Resize re-renders.
  • Live-preview viewportrenderLog caches per-row byte offsets so
    the cursor_moved handler can map the log cursor back to a commit
    index even for virtual buffers (where getCursorLine is unimplemented).
  • Open file at commitEnter on a diff line in the detail panel
    spawns git show <hash>:<path> into a named virtual buffer so syntax
    highlighting kicks in, then jumps to the target line.
  • Shared libraryplugins/lib/git_history.ts extracts the commit
    log + commit detail entry builders so audit_mode can reuse them for
    its branch-review mode.
  • Re-invocation — running "git log" while the group is already
    open pulls the existing tab to the front instead of erroring.
  • External close — subscribes to `buffer_closed` so state resets
    whether the close came from the plugin, the close button, or the
    "close buffer" command.

Engine fixes (dependencies of the plugin, reused elsewhere)

  • buffer_closed hook was never emitted. The variant existed in
    `fresh-core` but no editor code fired it, so every plugin that
    subscribed silently never heard. Now emitted from
    `close_buffer_internal`.
  • Non-scrollable panels. Separated scrollable from
    fixed-height in the layout API (optional `scrollable` field,
    defaults true for `Scrollable`, false for `Fixed`). Non-scrollable
    buffers ignore mouse wheel + shift+wheel, draw no scrollbar, and
    hidden-cursor panels reject focus from single/double/triple clicks
    and `focus_split` — plugins can still build button panels via
    `mouse_click`.
  • Buffer-group cursor routing.
    • `active_cursors` / clipboard now routes to the focused inner
      panel instead of the outer split.
    • `focused_group_leaf` is preserved across tab switches.
    • Cursor position snapshot prefers the group-panel split where the
      buffer is actually active.
    • Set-panel-content preserves each split's cursor in place.
    • `shift+wheel` does horizontal scrolling even when no scrollbar
      is drawn.
  • Mode inheritance. Plugin modes can now be registered with
    `inheritNormalBindings: true`, so `git_log` gets arrow keys,
    page-up/down, Shift+motion selection, Ctrl+C copy etc. for free
    without redeclaring them.
  • Overlay perf. Bulk-add path in `set_virtual_buffer_content`
    so the detail panel doesn't rebuild overlays one-by-one when
    switching commits.
  • Workspace. Read-only flag now persists across session restore.

Unrelated fixes that travelled with this branch

  • Tabs. Inactive-split active tab was invisible on the
    high-contrast theme (`tab_active_fg` == `tab_inactive_bg` = black).
    Uses `tab_inactive_fg + tab_inactive_bg + BOLD` instead.
  • Grammar lookup. `set language: TypeScript` used to leave
    buffers unhighlighted because syntect has no TypeScript grammar and
    the name-based lookup had no tree-sitter fallback (only the
    path-based lookup did). Added the fallback to
    `HighlightEngine::for_syntax_name`, stopped
    `DetectedLanguage::from_syntax_name` from bailing, and added
    `available_grammar_info_with_languages` so `fresh --cmd grammar
    list` shows TypeScript (extensions come from `LanguageConfig`,
    not hardcoded).
  • i18n. Audit mode string fixes.

Showcase

Blog demo at `docs/blog/productivity/git-log/` with frame-by-frame
SVG animation of the plugin, plus an e2e harness in
`tests/e2e/blog_showcases.rs`.

Test plan

  • `cargo nextest run -p fresh-editor` clean
  • `git log` opens, j/k live-updates detail, `Enter` on a diff
    line opens the file at the correct hash+line
  • Clicking a toolbar button dispatches
  • Closing via `q`, tab close button, or `close buffer` command
    all reset state (re-open works)
  • Running "git log" while open brings the tab forward
  • Mouse wheel over toolbar does nothing; click doesn't steal focus
  • `set language: TypeScript` on a `scratch` buffer highlights
  • `fresh --cmd grammar list` shows TypeScript
  • Vertical split on high-contrast theme: inactive pane's tab is
    legible

🤖 Generated with Claude Code

claude and others added 30 commits April 14, 2026 06:20
Motivation
----------
The git_log plugin swapped a single virtual buffer between log / detail
views, rebuilt colouring via imperative overlay passes, used hard-coded
RGB triples, and had no live preview. Tests (and screenshots) showed
misaligned columns and colours that drifted from the active theme.

This switches it to the modern plugin primitives exercised by audit_mode
and theme_editor: one `createBufferGroup` tab with log + detail panels
side-by-side, `setPanelContent` with `TextPropertyEntry[]` + `inlineOverlays`
for colour, and a `cursor_moved` subscription that live-updates the right
panel as the user scrolls through the log.

Shared rendering
----------------
Commit-list and commit-detail rendering move into a new
`plugins/lib/git_history.ts` module so the same helpers can be reused by
audit_mode's new "Review PR Branch" view. Every colour is a theme key
(`syntax.number`, `editor.selection_bg`, `editor.diff_add_bg`, …) so the
panels follow theme changes automatically. Column widths are computed per
render, producing properly aligned hash / date / author columns.

audit_mode: Review PR Branch
----------------------------
`start_review_branch` opens a matching group (commits on the left, `git
show` of the selected commit on the right) so a reviewer can step through
every commit on a PR branch without leaving the editor. It reuses the
same `buildCommitLogEntries` / `buildCommitDetailEntries` helpers, so
both plugins stay visually consistent.

Tests
-----
`test_git_log_open_different_commits_sequentially` previously asserted
that the newly-selected commit's message *replaced* the prior commit on
screen — the new layout keeps the full log visible on the left, so it
now asserts against the detail panel's file set instead (file2.txt
present, file3.txt absent).
Adds `blog_showcase_productivity_git_log` alongside the other blog
showcases. It builds a hermetic 6-commit repo (two authors, a v0.1.0
tag) and scripts the user walking through the modernised git-log panel:
open via the command palette, navigate with j/k and watch the right
panel live-update, Tab into the detail panel, q back to the log, q to
close the group.

The test is `#[ignore]` like every other blog showcase — run it with
`--ignored` to emit SVG frames into `docs/blog/productivity/git-log/`
and then `scripts/frames-to-gif.sh docs/blog/productivity/git-log`
to assemble the final animated GIF for the blog.
The live-preview panel re-ran the full log render and a fresh `git show`
on every cursor_moved event, so held j/k or PageDown could pile up
hundreds of synchronous renders and spawn hundreds of git subprocesses
on the main thread; a 40–60 ms debounce in `on_git_log_cursor_moved`
collapses bursts to a single render for the final row.

`fetchCommitShow` now does a cheap `git show --numstat` pre-pass and
excludes any file with >2000 lines changed from the subsequent
`--stat --patch` via `:(exclude,top)` pathspecs, with a footer listing
the skipped paths. Stops generated SVGs / lockfiles from turning the
detail panel into a 19 MB, ~500k-line buffer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`OverlayManager::add` re-sorts the full overlay vector on every insertion,
so rebuilding a virtual buffer from N overlays was O(N² log N). A big
`git show` can easily collect ~500k overlays (one per line + inline
highlights), which stalls the main thread for minutes.

Add `OverlayManager::extend` that appends all overlays and sorts exactly
once, and switch `set_virtual_buffer_content` to build the full vec first
and call `extend`. The per-overlay marker creation still runs N times
(unavoidable until the marker list learns a bulk insert), but the sort
cost drops from O(N² log N) to O(N log N).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a plugin rebuilt a group-panel's contents via setPanelContent, the
log cursor snapped back to row 0 on every render. set_virtual_buffer_content
looked up the old cursor position via has_buffer (which checks open_buffers),
but group-panel buffers are intentionally stripped from every split's
open_buffers list when the group is created — so the lookup always missed,
old_cursor_pos defaulted to 0, and the restore step overwrote every
keyed_states entry with 0.

Look up the prior cursor via keyed_states directly so panel buffers are
found. Reproduced by a new e2e: pressing Down repeatedly in the git-log
panel now progresses through commits; previously the second Down stuck
on the same row because the first render had reset the cursor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Git diffs frequently have lines longer than the 40% right-split width, so
horizontal scrolling was the only way to read them. Turn on line wrap for
the detail buffer when the group opens.
Git stores commit messages with their author-chosen hard wraps (typically
72 cols). In a narrow detail panel the built-in soft-wrap had to wrap
those already-short lines again, producing a staircase of half-width
lines. Rejoin each paragraph into a single logical line before emitting
entries so soft-wrap has room to break naturally at the panel edge.

Diff lines, Author/Date/Commit header, and merge/trailer blocks are
untouched — only the indented body region between the header and the
first `diff --git` line is rejoined.
Previous fetchCommitShow ran `git show --stat --patch` and tried to reflow
the commit message by walking the unified output. That conflated the
diffstat block with the message body (stat lines sit between them and
were inadvertently joined into a paragraph), and had no reliable way to
detect paragraph boundaries in merge commits or trailers.

Split into three calls:
  1. `git show --numstat --format=` — spot oversized files.
  2. `git log -n1 --format=%H%x00%P%x00%an%x00%ae%x00%aD%x00%B` —
     structured metadata + raw message.
  3. `git show --format= --stat --patch` (with pathspec exclusions for
     oversized files) — stat + patch only, no metadata.

Compose the final output ourselves: reconstruct the commit/Merge/Author/
Date header, reflow just the message paragraphs (blank lines preserved
as paragraph separators), then append the untouched stat + patch. The
stat block is never reflowed.
Match the review-diff layout style: a 1-row fixed toolbar panel above
the log + detail split, replacing the per-panel footer lines that
cluttered the bottom of the log and detail buffers and shifted as the
buffers re-rendered.

Keys render bold; labels dim; vertical-bar separators between groups.
The layout now is:

  ┌──── toolbar (1 row) ────────────────────────┐
  ├──── log (60%) ───────┬──── detail (40%) ────┤
  └──────────────────────┴──────────────────────┘
spawnProcess can't send a raw NUL byte as a process argument — it fails
CString conversion with "nul byte found in provided data" and the commit
detail pane errors out. Git's format language accepts the text `%x00` and
emits a literal NUL on its output side, which is what we actually want
for field parsing.
Reflow-as-paragraphs destroys intentional formatting in commit messages
that use lists, code blocks, or explicit short lines for emphasis —
the very kind of message where layout matters. Soft-wrap alone still
handles overly-long lines in narrow panels; preserving the author's
line breaks is the less-surprising default.

Go back to a single `git show --stat --patch` call (plus the existing
numstat pass for large-file exclusion). No message post-processing; no
header reconstruction.
set_virtual_buffer_content had a weird save-one/write-all shape: it read
the cursor position out of one view state (picked by a non-deterministic
`find`), and then copied that single value into every keyed_states entry
for the buffer. That clobbered the inner panel's live cursor with the
outer split's stale entry, so every re-render of the group panel yanked
the log cursor back to wherever the outer split happened to remember.

The right thing is simpler: each split tracks its own cursor, so on a
content swap we only need to clamp any position that fell past the new
buffer end and snap to a char boundary. No cross-split copying.
The sticky toolbar already labels the panel implicitly, and the sticky
tab title says "Git Log", so the header row was pure clutter — and
pushed the first commit off row 0, forcing a fiddly +1/-1 in the
cursor-byte-to-index mapping.

Pass `header: null` to `buildCommitLogEntries` (new supported value),
remove the now-unused byteOffsetOfFirstCommit helper, and simplify
indexFromCursorByte to identity.
Two problems in the git-log mode bindings:
  * `PageUp` / `PageDown` were bound to `page_up` / `page_down`, which
    aren't valid action names — the built-in actions are called
    `move_page_up` / `move_page_down`. The keys silently did nothing.
  * No Shift+arrow bindings, so selecting text for Copy was impossible
    in either panel.

Plugin modes don't fall through to Normal bindings (only
`CompositeBuffer` does, per `allows_normal_fallthrough`), so every key
has to be re-declared inside the mode. Add the missing motions and
every Shift+motion variant.
Plugin modes registered with defineMode() previously had to redeclare
every motion, selection, and copy binding because the resolver only
falls through to Normal for the CompositeBuffer context. That made even
read-only viewer modes (git_log, audit log views) carry a wall of
Up/Down/Page*/Home/End/Shift+arrow boilerplate that just re-pointed to
the built-ins.

Add an `inheritNormalBindings` flag to defineMode():
  * BufferMode gains an `inherit_normal_bindings` field + builder.
  * PluginCommand::DefineMode carries the flag through the host boundary.
  * KeybindingResolver tracks inheriting modes in a HashSet and treats
    `Mode(name)` as a full-fallthrough context when the name is in it.
  * QuickJS `defineMode` accepts a 5th optional `inherit_normal_bindings`
    argument; fresh.d.ts regenerated.

Use it in git_log: drop the redeclared motion/selection bindings,
keeping only j/k vi aliases and the six plugin-specific action keys
(Return/Tab/q/Escape/r/y). PgUp/PgDn, Home/End, Shift+arrows, Ctrl+C
now work because they fall through to Normal.
…uter split

When a buffer-group panel had focus and you made a selection there with
Shift+arrows, Ctrl+C copied the current line instead of the selection
and showed "copied line". The selection lived in the panel's cursors,
but `active_cursors()` returned the outer split's cursors (which had no
selection), so `copy_selection` took the no-selection branch and copied
the whole line.

`active_buffer()` / `active_state()` already route through
`effective_active_pair()` to pick the focused panel; cursor access
should be consistent. Switch `active_cursors`/`active_cursors_mut` to
`effective_active_split` so they read from the panel's view state too.
When a buffer-group tab lost focus and the user switched back to it,
the focused panel jumped back to whichever leaf the SplitNode::Grouped
node was originally created with — losing the user's last-focused
panel. focus_panel() updated `vs.focused_group_leaf` but not
`SplitNode::Grouped.active_inner_leaf`, so on the next
`activate_group_tab` the stored value clobbered the live one.

Update active_inner_leaf inside focus_panel as well, so the persisted
preference matches the current focus and tab-away/back is a no-op.
The plugin snapshot built on every render captures each buffer's cursor
position by iterating split_view_states and taking the first one whose
keyed_states has the buffer. HashMap iteration is non-deterministic, and
buffer-group panel buffers linger in both the outer split's keyed_states
(never updated — stuck at 0) and the inner panel's (live). Half the time
the snapshot recorded the stale 0 instead of the real cursor.

That manifested as flaky behavior in git_log's detail panel: pressing
Enter on a diff line sometimes worked and sometimes said "Move cursor to
a diff line with file context", because getTextPropertiesAtCursor was
reading the snapshot's cursor and hitting the wrong byte.

Match the fix we already applied to set_virtual_buffer_content: prefer
the view state where `active_buffer == buffer_id` (where motion actions
actually write), then fall back to any keyed_states entry.
Files marked read-only via mark_buffer_read_only lost the flag on
restart. The warning log (opened from the status-bar indicator) would
come back editable. Capture read-only file paths on save and re-apply
mark_buffer_read_only to matching restored buffers.

Stored relative to working_dir when possible, absolute otherwise, to
match how external_files and open_tabs paths are handled. Field is
additive with serde(default), so older session files still load.
Move the detail cursor back to byte 0 after every renderDetailForCommit
so the view shows the top of the new commit's diff instead of wherever
the cursor happened to land in the previous commit's content.
handle_horizontal_scroll clamped rightward scroll to `max_line_length_seen
- visible_width`, but max_line_length_seen is only updated during the
render loop and starts at 0. In any buffer where the currently-visible
lines fit the viewport, that stored value equals visible_width, making
the clamp pin left_column at 0 — shift+wheel right did nothing even
when long lines existed further down.

Drop the clamp; the render pass already clips what's drawn, so mild
overshoot is harmless, and the common case (visible content fits but
user wants to scroll) now works.
The virtual-buffer name was "<path> @ <hash>", so the host's
from_virtual_name detector ran from_path_builtin on "<hash>" — no
extension match, no highlighter. Switch to "*<hash>:<path>*" which
matches the documented convention; rfind(':') grabs the path and
the extension picks the right grammar.
Pressing Enter on a diff line showed the file at that commit but left
the cursor at byte 0 — the user still had to find the line themselves.
Resolve the target line's byte offset via getLineStartPosition and
setBufferCursor to it before the status message renders.
Diagnostic logs at two places:
  * snapshot writer — records `buffer_id -> (cursor_pos, source split)`
    at trace level for every snapshot refresh.
  * plugin runtime's getTextPropertiesAtCursor — debug log with the
    snapshot cursor, fallback cursor, active buffer, and match counts.

To reproduce the flaky "Move cursor to a diff line with file context"
path: run with RUST_LOG=fresh=trace,fresh_plugin_runtime=debug and
compare a working run with a failing one.
The trace from the flaky-open-file repro showed two splits advertising
`active_buffer == BufferId(4)` for the git-log detail panel — the panel
leaf (SplitId(3), live cursor=1312) and the outer host split (SplitId(0),
stale cursor=0). HashMap iteration picked whichever came first, and the
order flipped after closing a file-view buffer, so the plugin snapshot
suddenly recorded byte 0 and `getTextPropertiesAtCursor` returned the
wrong row, status: "Move cursor to a diff line with file context".

Prefer the view state with `suppress_chrome == true` (panel splits get
that flag when the group is created) before falling back to any split
that has the buffer active or keyed.
…yed_states

Panel buffers were added to the active (outer) split's keyed_states when
create_virtual_buffer ran, then createBufferGroup only scrubbed them
from open_buffers — not keyed_states. The outer split kept a stale
cursor entry for the panel buffer forever, which collided with the
panel's own view state in any HashMap scan keyed by buffer id. Symptom:
flaky "Move cursor to a diff line with file context" after toggling
in/out of a file-view, and the snapshot trace showing the cursor source
flip between the panel split and the outer split for the same buffer.

Clean panel-buffer entries out of every non-panel split's keyed_states
at group-creation time, matching what we already do for open_buffers.
With no duplicate entries left, the snapshot's lookup is deterministic:
exactly one split has the panel buffer in keyed_states — the panel's
own. Drop the now-unnecessary suppress_chrome priority layering.
Cover the sequence that was flaky before the panel-buffer keyed_states
cleanup: open git-log, Enter on a diff line to open the file-view,
close it with q, move in the detail panel, Enter again. Pre-fix, the
second Enter reported "Move cursor to a diff line with file context"
because the outer split still carried a stale cursor entry for the
panel buffer. The test asserts that status line is absent.

Also drop the stale wait-for-"Commits:" assertion in the existing
down-arrow progression test — the header row was removed in an earlier
commit, so the test would hang forever on that substring.
The "Commits:" header row was dropped in 268d1d0 and the detail panel
became a live-preview on cursor move, but the tests still waited for
"Commits:" and drove Enter+q to open/close commit detail. Switch the
sentinel to a toolbar hint ("switch pane") that's absent from the
status bar, and rewrite the sequential-open test as a pure Down-key
navigation check mirroring test_git_log_down_arrow_progresses_through_commits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sinelaw sinelaw force-pushed the claude/modernize-git-log-plugin-ZmH4O branch from 8d1a255 to ce00ab7 Compare April 14, 2026 20:18
sinelaw and others added 6 commits April 14, 2026 23:36
Fixed panels (toolbars, headers, footers) previously took mouse wheel
events, drew a scrollbar, and could steal keyboard focus via clicks —
so hovering a toolbar and scrolling would shift its pinned content by a
line, and clicking it routed arrow keys to an invisible cursor.

Separate "scrollable" from "fixed-height" as its own layout property
(default true for Scrollable, false for Fixed; either can override).
Non-scrollable buffers now ignore vertical and horizontal mouse scroll,
skip scrollbar rendering entirely, and buffers with hidden cursors
reject focus from left/double/triple clicks and focus_split — plugins
can still observe clicks via the mouse_click hook to build buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Render each toolbar hint as a discrete button with its own background
and capture per-button column ranges so mouse_click on the toolbar
dispatches the matching handler (Tab, RET, y, r, q). Keyboard-only
cursor motions (j/k, PgUp/PgDn) are dropped from the toolbar entirely
since clicking them is meaningless.

Also drop the Escape → close binding and rename "yank hash" to
"copy hash" to match the rest of the editor's vocabulary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The BufferClosed hook variant was declared in fresh-core but never
emitted by the editor, so plugins subscribing to "buffer_closed" never
heard about closures driven by the tab close button or the "close
buffer" command. Emit it at the end of close_buffer_internal.

Git-log now subscribes to buffer_closed and runs a shared cleanup path
whenever any of its panel buffers disappears, so a later "git log"
invocation doesn't see a stale isOpen=true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Running "git log" while the group is already open now pulls its tab to
the front instead of flashing an "already open" status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The inactive split's active tab paired `tab_active_fg` with
`tab_inactive_bg`. That combo happens to be black-on-black on the
high-contrast theme (active_fg = inactive_bg = [0,0,0]), making the tab
label disappear until the split regained focus.

Switch to `tab_inactive_fg + tab_inactive_bg + BOLD` for that case —
bold still signals which tab is active in the inactive split, and both
colors come from the inactive palette so contrast is guaranteed across
every built-in theme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rammars

TypeScript ships as a tree-sitter grammar only — syntect has nothing
for it. The path-based highlighter already fell back to tree-sitter in
that case, but the name-based lookup used by the language palette and
the CLI grammar listing did not, so:

  * opening foo.ts → highlighted
  * set language: TypeScript on a new buffer → no highlighting
  * fresh --cmd grammar list → no TypeScript entry

Extend HighlightEngine::for_syntax_name with the same tree-sitter
fallback that for_file has, stop DetectedLanguage::from_syntax_name
from bailing when only tree-sitter knows the name, and add an
available_grammar_info_with_languages variant that merges tree-sitter
languages from the user config (using LanguageConfig.extensions rather
than hardcoded tables). The CLI grammar-list command now loads config
and calls the new method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sinelaw sinelaw changed the title Add git log showcase with shared git history rendering Magit-style git log plugin + buffer-group/rendering fixes Apr 14, 2026
sinelaw and others added 6 commits April 15, 2026 00:38
When the user closed a tab in the host split and the LRU fallback was a
buffer group, close_buffer_internal reached into the group's active
inner panel leaf and picked that panel buffer as the host split's
replacement active_buffer. set_active_buffer → SplitView::switch_buffer
then auto-inserted a fresh default BufferViewState into the host split's
keyed_states for that panel buffer — a shadow entry frozen at cursor=0
that never gets updated because motion goes to the panel split via
effective_active_split.

The shadow collided with the panel split's authoritative entry any time
update_plugin_state_snapshot iterated split_view_states to look up the
buffer's cursor_pos. HashMap order decided which entry won, so plugin
getTextPropertiesAtCursor reads were non-deterministic — usually fine,
sometimes hitting the shadow's cursor=0 and returning properties for
the detail panel's header row (no file context), at which point
git_log's Enter handler reported "Move cursor to a diff line with file
context" even though the visible cursor was on a valid diff line.

Pick a replacement buffer that's already in the host split's
keyed_states, so switch_buffer's "if !contains_key { insert default }"
path doesn't fire and no shadow is created. Panel buffers now appear in
exactly one split's keyed_states — the panel split — making the
snapshot lookup deterministic without needing the open_buffers /
focus_history scrub that the previous approach layered on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously q on the detail panel stepped back to the log panel and only
q on the log panel closed the group. The two-step close was surprising —
users pressed q on the detail panel expecting the group to close.

Always close on q.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The guards that make fixed toolbars/headers inert keyed off `!show_cursors`,
but every buffer-group panel (scrollable and fixed alike) has `show_cursors`
set to false. That meant clicks on interactive scrollable panels were also
swallowed — focus never moved, so mouse-driven panel switching inside a
group was a no-op.

Switch the three guards (single/double/triple click and `focus_split`) to
the `scrollable` property via the existing `is_non_scrollable_buffer`
helper. Fixed panels stay inert; scrollable panels with hidden cursors
again accept focus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- `test_git_log_back_from_commit_detail` predated the "q closes group
  from any panel" change and looped forever waiting for the toolbar it
  had just closed. Renamed + rewritten as
  `test_git_log_q_from_detail_closes_group` to cover the new contract.

- `test_git_log_open_different_commits_sequentially` waited on the
  toolbar's "switch pane" hint and then asserted commit rows immediately;
  on slow runs the log panel was still "Loading git log..." when the
  assertion fired. Wait on the commit rows themselves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mediately

Previously every `cursor_moved` event delayed all downstream work by the
debounce window — the log-panel selection highlight, the detail
placeholder, and the status line all waited 60 ms before updating. Held
j/k felt sluggish even though the expensive bit is only the `git show`
process spawn.

Split the detail refresh into a synchronous "render cache or placeholder"
phase and an async "spawn + render" phase. The cursor handler now runs
the synchronous phase on every event (so highlight + "loading…" flip
instantly) and only debounces the spawn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Name]

When the LRU replacement target was a Group tab and the host split's
`keyed_states` contained no suitable buffer — the common case after
closing the last file tab alongside an audit/git-log group — the close
path fell through to `new_buffer()` and the synthesized `[No Name]`
showed up next to the activated group tab. `created_empty_buffer` then
also routed focus to the file explorer.

Pick any remaining buffer (including hidden panel buffers) to fill the
host split's `active_buffer` housekeeping slot before resorting to
`new_buffer`. A hidden panel buffer in that slot leaves a harmless
shadow entry in the host's `keyed_states` (required by the
`active_buffer ∈ keyed_states` invariant), so teach the plugin-state
snapshot lookup to skip group-host splits when resolving a hidden
buffer's cursor position — the panel's inner split is the authoritative
home. The tab entry and focus-history entry are still scrubbed so the
panel buffer never surfaces as a tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sinelaw sinelaw merged commit 548b017 into master Apr 14, 2026
6 of 8 checks passed
@sinelaw sinelaw deleted the claude/modernize-git-log-plugin-ZmH4O branch April 14, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants